/**
* PATCH /ds-devices/{serial_number}/note — D-S制御盤の備考を保存する
*
* 仕様の正は `d-watch-api.yaml` の /ds-devices/{serial_number}/note です。
* D-Secログ画面(F-06)の「備考」列に運用担当が入力したメモを保存します。
*
* ★保存先はメインDBではありません★
* `lib/settings/ds-device-notes.ts`(DFA独自のJSONストア)に書きます。
* メインDBはTeamWorks管理で、DFAが更新できるのは
* `robot_error_reports` / `control_error_histories` の4カラムだけです(CLAUDE.md 7)。
*
* 空文字(トリム後も含む)を送ると備考は削除され、レスポンスの `note` は null になります。
* 削除を別エンドポイントにしないのは、画面が「テキストを空にして保存」しか操作を持たず、
* 保存と削除を呼び分ける判断をフロントに持たせる意味が無いためです。
*/
import { NextResponse } from "next/server";
import { parsePathSerialNumber } from "@/lib/api/path-params";
import { DEVICE_NOTE_MAX_LENGTH } from "@/lib/config";
import { db } from "@/lib/db";
import { withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import { saveDeviceNote } from "@/lib/settings/ds-device-notes";
import type { ApiError, ErrorCode } from "@/lib/types";
const ROUTE = "/api/ds-devices/[serial_number]/note";
function errorBody(
code: ErrorCode,
message: string,
details: Record<string, string[]> | null = null
): ApiError {
return { error: { code, message, details } };
}
function errorResponse(
status: number,
code: ErrorCode,
message: string,
details: Record<string, string[]> | null = null
) {
return NextResponse.json(errorBody(code, message, details), { status });
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ serial_number: string }> }
) {
return withRequestId(request, async () => {
const { serial_number: serialNumberRaw } = await params;
// ★形式の検証を実在チェック(`db.dsDeviceExists()`)より前に置く★
// DBのカラム幅(`controls.serial_number` = varchar(100))に入りえない長さの入力を
// DBの照会まで運ぶ意味が無い。指示書AE 段階2-2 / D-123
// ★★検証順序(形式 → 長さ → 実在)を変えないこと★★(テスト #6 / #7 が固定している)
const parsedSerial = parsePathSerialNumber(serialNumberRaw);
if (!parsedSerial.ok) {
return NextResponse.json(parsedSerial.error, { status: parsedSerial.status });
}
const serialNumber = parsedSerial.query;
let body: { note?: unknown };
try {
body = await request.json();
} catch {
return errorResponse(422, "validation_failed", "JSONの形式が不正です");
}
const note = body.note;
if (typeof note !== "string") {
return errorResponse(
422,
"validation_failed",
"noteは文字列で指定してください"
);
}
// 文字数はトリム前の入力そのもので数える。
// 画面の入力欄が数えるのもトリム前の文字数なので、
// ここでトリム後を見ると「画面上は上限内なのに保存が弾かれる」(逆も)ズレが出る
if (note.length > DEVICE_NOTE_MAX_LENGTH) {
return errorResponse(
422,
"validation_failed",
"noteが長すぎます",
{ note: [`${DEVICE_NOTE_MAX_LENGTH}文字以内で入力してください`] }
);
}
// 実在しないシリアル番号の備考を保存すると、どのデバイスからも参照されない
// 行がストアに残り続ける(ストアはメインDBを参照しないため自力で気付けない)ので、
// 保存前に制御盤マスタと突き合わせる。
//
// ★★`db.dsDevices()`(一覧)を呼ばないこと★★(指示書AH 段階2)—
// シリアル1件の実在確認のために全制御盤+全施設を読む必要は無く、
// ★無関係な1台の欠損(心拍0件)で例外が飛び、全台の備考保存が500になる★
// 経路も断てる。★照合規則(ci 照合順序への対処。D-4)は `dsDeviceExists()` 側の正★。
let exists: boolean;
try {
exists = await db.dsDeviceExists(serialNumber);
} catch (e) {
const errorPayload = errorBody("internal_error", "データの取得に失敗しました");
logApiError({
route: ROUTE,
method: "PATCH",
body: errorPayload,
status: 500,
err: e,
});
return NextResponse.json(errorPayload, { status: 500 });
}
if (!exists) {
return errorResponse(404, "not_found", "デバイスが見つかりません");
}
let saved: string | null;
try {
saved = await saveDeviceNote(serialNumber, note);
} catch (e) {
const errorPayload = errorBody("internal_error", "備考の保存に失敗しました");
logApiError({
route: ROUTE,
method: "PATCH",
body: errorPayload,
status: 500,
err: e,
});
return NextResponse.json(errorPayload, { status: 500 });
}
// 保存後の値をエコーバックする。ストア側がトリム・削除を行うため、
// 画面はリクエストで送った文字列ではなくこの値で表示を更新する必要がある
return NextResponse.json({ serial_number: serialNumber, note: saved });
});
}